<?php
/**
 * Class for automated manipulation of plain HTML pages as advanced templates with _(phpQuery).
 * @see phpQuery.class.php
 * @author Tobiasz Cudnik <tobiasz.cudnik/gmail.com>
 * @link http://meta20.net/plainTemplates
 * @license http://www.opensource.org/licenses/mit-license.php MIT License
 * @version 0.5 beta
 * @static 
 */
class plainTemplates {
	/**
	 * Turns on/off debug. Includes phpQuery's one.
	 * @param bool
	 * @see phpQuery::$debug
	 */
	public static $debug = false;
	/**
	 * Path to direcotry where cacheed templates will be stored.
	 * @var string
	 * @see self::$cacheTimeout
	 */
	public static $cacheDir = './cache/';
	/**
	 * Checks if source template file exists and if it has been modified.
	 * Can be turned off for performance reasons on production enviroment.
	 * @var bool
	 */
	public static $monitorTemplateModification = true;
	/**
	 * Checks if source code file has been modified.
	 * Use it only on developement enviroment as it runs debug_backtrace() every time.
	 * @var bool
	 */
	public static $monitorCodeModification = true;
	/**
	 * Path prepended to template names.
	 * @var string
	 */
	public static $templatesDir = '';
	/**
	 * Timeout for cached templates in minutes.
	 * Generally uneeded because of checking templates modification time.
	 * False (default) means no timeout.
	 * @var mixed
	 * @see self::$cache
	 * @see self::$cacheDir
	 */
	public static $cacheTimeout = false;
	/**
	 * If true (default) prints template result to buffer, instead of returning it's string (optional).
	 * @var bool
	 */
	public static $print = true;
	/**
	 * Use HTML Tidy for pretty output (if avaible).
	 * Tidy is included with every PHP5 installation, but it has be manually activated into php.ini.
	 * However there are several issues with Tidy at this time.
	 * @todo Strip out HTML/BODY if it's soudlnt be in the template (something what show-body-only should do).
	 * @var bool
	 */
	public static $useTidy = false;
	/**
	 * Config fo Tidy.
	 * @link http://tidy.sourceforge.net/docs/quickref.html
	 * @var array
	 */
	public static $tidyConfig = array(
		'indent' => true,
		'indent-spaces' => 4,
		'wrap' => false,
		'show-body-only' => 'yes',
		'merge-divs' => false,
		'new-blocklevel-tags' => 'php',
	);
	/**
	 * @var array
	 */
	public static $dataSelectors = array(
		// CSS selector for wrapper. Wrapper is an elements that wraps the container and optionally other elements (like title).
		'wrapper'	=> '',
		// CSS selector for loop's container. Relative to wrapper.
		'container'	=> 'ul',
		// CSS selector for row. Relative to container.
		'row'		=> '>li',
		// CSS selector for row field. Relative to row. Use %s as field name.
		'field'		=> '.%s',
		// Removes field's class from matched field tag.
		'removeFieldClasses' => false,
	);
	/**
	 * Fixes paths to CSS, JS and image files.
	 * @todo Dont update external links.
	 * @var string
	 */
	public static $fixWebRoot = '';
	protected static $replace = array(
		'<php>'		=> '<?php ',
		'</php>'	=> ' ?>', 
		// TODO: rewrite those below to regexp searching only inside php tags, but not in string, maybe with support of "{$nestedVars} in strings"
		'%7B' => '{',
		'%24' => '$',
		'%7D' => '}',
	);
	protected static $_template = null;
	/**
	 * Creates template from plain HTML file or certain part of it (with $source[1]).
	 * If You want to insert DB data into template use $data and $dataSelectors.
	 * You can apply callback on result with $callback.
	 * 
	 * @param string|array	$source		Template file path as string or as [0] if array given. Relative to self::$templatesDir.
	 * $source[1] is CSS selector that limits template to part(s) matching the selector.
	 * Limiting template changes result's stack, except when $data present.
	 * @param array		$data			Optional. Containing var name (on [0]), value (on [1]) and selectors (on [2]). Name is needed to preserve scope.
	 * Selectors are inherited from plainTemplates::$dataSelectors.
	 * Data insertion changes result's stack to match place(s) where data has been inserted.
	 * If you need another state of the result's stack - change it manually in callback.
	 * $data[2] can be a string, then it's used as 'wrapper'.
	 * @param mixed		$callback		Optional. Can be result of create_function(), function name, array with object reference in [0] and method name in [1] or array with class name in [0] and static method name in [1].
	 * Callback called after template parsing.
	 * Can return phpQueryClass object to change stack. 
	 * @param string	$cacheName		Optional. Needed when You want to build two templates with same selectors from one HTML. Also helps finding template inside cache dir.
	 * 
	 * @return string|false				Returns include path or false in case of error.
	 */
	public static function createTemplate( $source, $data = array(), $callback = null, $templateName = null ) {
		$templatePath = is_string($source)
			? $source
			: $source[0];
		if ($data && isset($data[2]) && is_string($data[2]))
			$data[2] = array('wrapper' => $data[2]);
		$templateName = $templatePath.$templateName;
		if ($data && isset($data[2]))
			$templateName .= '-'.md5(serialize($data[2]));
		// check cache
		if ( $includePath = self::getTemplate($templateName, $templatePath) )
			return $includePath;
		self::includePhpQuery();
		self::$_template = phpQuery(self::$templatesDir.$templatePath);
		if (! self::$_template) {
			throw new Exception("Cant load template '".self::$templatesDir.$templatePath."'");
			return false;
		}
		$dependencies = self::parseIncludes(self::$_template);
		// limit template to certain part
		if (is_array($source) && isset($source[1]))
			self::$_template = self::$_template->find($source[1]);
		// insert data
		if ($data)
			self::$_template = self::insertData($data);
		// run callback
		if ($callback) {
			// we need to jump to parent, because ROW templates have stack set to all row's top elements
			// its for to bypass html/body problem when joining with wrapping template
			$isRowTemplate = $data && (! isset($data[2]['container']) || ! $data[2]['container']);
			$_forCallback = $isRowTemplate
				? self::$_template->parent()
				: self::$_template;
			$_new = self::runCallback($_forCallback, $callback);
			// callback changed stack, keep it like this
			if ( $_new )
				self::$_template = $_new;
			// ROW template once again, now we need to repoint our stack
			else if ($isRowTemplate)
				self::$_template = self::$_template->parent()->find('> *');
		}
		return self::saveTemplate(self::$_template, self::$templatesDir.$templatePath, $templateName, $dependencies);
	}
	/**
	 * Returns cached template's path for inclusion.
	 * Returns false if cache isn't up-to-date.
	 * 
	 * @return string|false
	 */
	public static function getTemplate( $templateName, $templatePath, $cacheDir = null ) {
		self::includePhpQuery();
		list($cachePath, $cacheTime, $cacheDeps, $cacheSrc) = self::getCachePaths( $templateName, $cacheDir );
		$templatePath = self::$templatesDir.$templatePath;
		if ( file_exists($cachePath) ) { 
			$useCache = true;
			if ( self::$monitorTemplateModification ) {
				// check timeout (stiff refresh)
				if ( self::$cacheTimeout && time()-file_get_contents($cacheTime) > self::$cacheTimeout )
					$useCache = false;
				// check if template source have been modified
				if ( filemtime($templatePath) > file_get_contents($cacheTime) )
					$useCache = false;
				// check dependencies
				if ( file_exists($cacheDeps) ) {
					foreach( file($cacheDeps) as $line ) {
						list($file, $time) = explode("\t", $line);
						// check if template source have been modified
						if ( filemtime($file) > $time )
							$useCache = false;
					}
				}
			}
			if ( self::$monitorCodeModification ) {
				if ( file_exists($cacheSrc) ) {
					$srcFilePath = self::srcFilePath();
					if (! file_exists($cacheSrc) )
						$useCache = false;
					if ( filemtime($srcFilePath) > file_get_contents($cacheSrc) )
						$useCache = false;
				} else
					$useCache = false;
			}
			if ( $useCache )
				return $cachePath;
		}
		return false;
	}
	/**
	 * Creates new template and returns it's path.
	 * If You want to use self::$fixWebRoot You have to pass phpQueryClass object directly.
	 * CAUTION: this method will call unload() on passed phpQuery object, so You cant use it futher. Use $unloadDocument to avoid this.
	 * 
	 * @param phpQueryClass			$_				String from object will be fetched with htmlWithTag(), so take care of proper stack.
	 * @param string				$templatePath
	 * @param string				$templateName
	 * @param array					$dependencies
	 * @param string				$cacheDir
	 * @param bool					$unloadDocument
	 * 
	 * @return string|false
	 */
	public static function saveTemplate( $_, $templatePath, $templateName = null, $dependencies = array(), $cacheDir = null, $unloadDocument = true ) {
		// for performance cache stuff is checked only when writing
		if (! self::checkCache() )
			return false;
		$templatePath = self::$templatesDir.$templatePath;
		if (! $templateName )
			$templateName = self::cleanCacheName($templatePath);
		if ( $_->is('html') ) {
			// remove tmp stuff
			$_->find('[@rel=plainTemplates-tmp]')
					->remove();
		}
		if ( self::$fixWebRoot ) { 
			$selector = 'img[@src], link[@href], script[@src]';
			$filter = ':not([@href^=<?php]):not([@src^=<?php]):not([@href^=http://]):not([@src^=http://])';
			foreach( $_->find($selector)->filter($filter) as $el ) {
				if ( $el->is('img') || $el->is('script') )
					$el->attr('src', self::$fixWebRoot.$el->attr('src'));
				if ( $el->is('link') ) {
					$el->attr('href', self::$fixWebRoot.$el->attr('href'))
						->removeAttr('include');
				}
			}
		}
		$html = $_->htmlWithTag();
		if ( self::$useTidy && function_exists('tidy_parse_string')) {
			$tidy = tidy_parse_string($html, self::$tidyConfig); 
			$html = tidy_get_output($tidy); 
		}
		$html = self::PXTtoPHP($html);
		// needed to avoid conflicts
		if ( $unloadDocument )
			$_->unload();
		list($cachePath, $cacheTime, $cacheDepend, $cacheSrc) = self::getCachePaths( $templateName, $cacheDir );
		file_put_contents(
		 	$cachePath,
		 	$html
		);
		file_put_contents(
		 	$cacheTime,
		 	filemtime($cachePath)
		);
		// build dependencies info (only if needed)
		if ( $dependencies ) {
			$dependencies = array_map(
				array('plainTemplates', 'mapDepends'),
				$dependencies
			);
			file_put_contents(
			 	$cacheDepend,
			 	implode("\n", $dependencies)
			);
		}
		if ( self::$monitorCodeModification )
			file_put_contents(
			 	$cacheSrc,
			 	filemtime(self::srcFilePath())
			);
		return $cachePath;
	}
	/**
	 * Fetches included templates content into parsed one.
	 * 
	 * @param	phpQueryClass	$_
	 * @return	array			Array of dependencies.
	 */
	public static function parseIncludes($_) {
//		$rootDir = dirname(self::$templatesDir.$templatePath).'/';
		$rootDir = self::$templatesDir;
		// collect included templates name
		$dependencies = array();
		foreach( $_->find('object[@codetype=text/template]') as $include ) { 
			$includePath = $rootDir.$include->attr('data');
			if ( strpos($includePath, '{') !== false )
				continue;
			if ( file_exists($includePath) ) {
				$_nested = phpQuery($includePath);
				$dependencies[] = $includePath;
				$dependencies = array_merge(
					$dependencies,
					self::parseIncludes( $_nested )
				);
				$include
					->after($_nested)
					->remove();
			} else
				throw new Exception("File '{$includePath}' doesn't exists, couldn't include template");
		}
		// remove unincludable includes (with {n} args)
		self::removeIncludes($_);
		// dont loose main template
		phpQueryClass::$lastDocID = $_->docID;
		return $dependencies;
	}
	/**
	 * Removes included templates from parsed one.
	 * 
	 * @param	phpQueryClass	$_
	 * @return	phpQueryClass
	 */
	public static function removeIncludes($_) {
		$_->find('object[@codetype=text/template]')
			->remove();
	}
	/**
	 * Clears cache folder. Can be limited to files containing $search in names.
	 * Returns number of deleted files.
	 * 
	 * @param	string	$search	Optional. Search pattern, accepts wildcard. @see http://php.net/glob
	 * @return	int
	 */
	public function clearCache($search = '*') {
		$i = 0;
		foreach( glob(self::$cacheDir.$search.'.code.php') as $file ) {
			unlink($file);
			// TODO get cache names from self::getCachePaths() and delete other files
			$i++;
		}
		return $i;
	}
	
	protected function runCallback($_, $callback) {
		if ( is_array($callback) )
			if ( is_object( $callback[0] ) )
				return $callback[0]->{$callback[1]}($_);
			else
				return call_user_func($callback, $_);
		else
			return $callback($_);
	}
	protected static function mapDepends($file) {
		return $file."\t".filemtime($file);
	}
	protected static function srcFilePath() {
		$srcFilePath = array_slice(debug_backtrace(), 0, 3);
		return substr($srcFilePath[1]['file'], -strlen(__FILE__)) != __FILE__
			// direct calling of getTemplate() from another external file
			? $srcFilePath[1]['file']
			// call from plainTemplates::createTemplate()
			: $srcFilePath[2]['file'];
	}
	/**
	 * Enter description here...
	 *
	 * @param unknown_type $data	$data[1] is value, [0] is var name
	 * @param unknown_type $selectors
	 * @param unknown_type $suffix
	 * @return unknown
	 */
	protected static function insertData($data, $selectors = array(), $suffix = 'Row') {
		// overload selector
		if (! $selectors )
			$selectors = isset($data[2])
				? $data[2]
				: array();
		$selectors = array_merge(
			self::$dataSelectors,
			$selectors
		);
		$rowName = str_replace(array("['", "']", '[', ']'), '', $data[0]).$suffix;
		$selectors['_container'] = $selectors['wrapper'].' '.$selectors['container'];
		if ( $selectors['container'] )
			//	$replace = array("['", "']", '[', ']');
			// select container
			$container = self::$_template
				->find($selectors['_container'])
				->eq(0)
				->prependPHP('foreach( '.$data[0].' as '.$rowName.' ):');
		if (trim( $selectors['row'] )) {
			// select all rows
			$_rows = trim($selectors['_container'])
				? self::$_template->find($selectors['_container'])
					->eq(0)
					->find($selectors['row'])
				: self::$_template->find($selectors['row']);
//			$_rows = self::$_template->find($selectors['_row']);
			// choose first row as template
			$_row = $_rows->eq(0);
			// detach all others
			$_rows->gt(0)->remove();
		} else {
			$_row = trim($selectors['_container'])
				? self::$_template->find($selectors['_container'])
					->eq(0)
					->find('> *')
				: self::$_template->find(' > *');
		}
		if ( $selectors['container'] ) {
			foreach( $data[1] as $index => $row ) {
				// insert row's fields
				self::insertRow($selectors, $row, $_row, $rowName);
				break;
			}
			$_row->appendTo($container);
			// close the loop
			$container->appendPHP('endforeach;');
		} else
			self::insertRow($selectors, $data[1], $_row, $data[0]);
		// return result
		return $selectors['container']
			// return wrapper if present
			? ($selectors['wrapper'] ? self::$_template->find($selectors['wrapper'])->eq(0) : $container)
//			: $_row->find('> *')->htmlWithTag();
			: $_row->find('> *');
//			: $_row->findRoot();
//			: $_row->findRoot()->find('body');
	}
	/**
	 * @todo support empty field selectors (insert field data directly inside row selector
	 * @todo support defining field selectors per field
	 */
	protected static function insertRow($selectors, $row, $_row, $varName, $prefixes = array('','')) {
		// nested data
		if ( isset($row[0]) && is_array($row[0]) ) {
			if ( array_key_exists($prefixes[0], $selectors) ) {
				if (is_null($selectors[ $prefixes[0] ]))
					return;
			} else
				$selectors[ $prefixes[0] ] = array(
					'container' => "ul.{$prefixes[0]}"
				);
			$rowSelectors = is_string($selectors[ $prefixes[0] ])
				? array('wrapper' => $selectors[ $prefixes[0] ])
				: $selectors[ $prefixes[0] ];
			// suffix will be added to var names
			$suffix = str_replace('-', '_', $prefixes[0]);
			$data = array(
				$varName.$prefixes[1],
				$row
			);
			self::insertData($data, $rowSelectors, '');
		// row
		} else {
			$fieldPrefix = $prefixes[0]
				? $prefixes[0].'-'
				: '';
			foreach( $row as $field => $v ) {
				if ( is_array($v) ) {
					if (! $v )
						// empty array, eg defined model with no rows
						continue;
					self::insertRow($selectors, $v, $_row, $varName, array(
							$fieldPrefix.$field,
							$fieldPrefix."['{$field}']"
						)
					);
					continue;
				}
				// when we have field selector...
				if ( $selectors['field'] ) {
					$query = sprintf($selectors['field'], $fieldPrefix.$field);
					$_field = $_row->find($query)
						->php("print {$varName}{$prefixes[1]}['{$field}'];");
					if ( isset($selectors['removeFieldClasses']) && $selectors['removeFieldClasses'] )
						$_field->removeClass($fieldPrefix.$field);
				// when we dont have field selector, then we just put field's data inside row
				} else {
					$_row->php("print {$varName}{$prefixes[1]}['{$field}'];");
				}
			}
		}
	}
	protected static function checkCache() {
		 if (! self::$cacheDir ) {
		 	throw new Exception('Cache dir not set');
		 }
		 if (! file_exists( self::$cacheDir )) {
		 	throw new Exception('Cache dir doesnt exist');
		 }
		 if (! is_writable( self::$cacheDir )) {
			 if (! chmod( self::$cacheDir, 0666 )) {
			 	throw new Exception('Cache dir not writable');
			 }
		 }
		 self::$cacheDir = rtrim(self::$cacheDir, '/').'/';
		 return true;
	}
	protected static function getCachePaths( $templateName, $cacheDir = null ) {
		$replace = array('/', '\\');
		if (! $cacheDir )
			$cacheDir = self::$cacheDir;
		$clearCacheName = self::cleanCacheName($templateName);
		return array(
			$cacheDir.$clearCacheName.'.code.php',
			$cacheDir.$clearCacheName.'.time.php',
			$cacheDir.$clearCacheName.'.deps.php',
			$cacheDir.$clearCacheName.'.src_time.php',
		);
	}
	protected static function cleanCacheName( $name ) {
		return str_replace(array('/', '\\'), '_', $name);
	}
	protected static function PXTtoPHP($PXT) {
		$PXT = preg_replace_callback(
			'@<php>(.*?)(</php>)@s',
			create_function('$m',
				'return "<php>".htmlspecialchars_decode($m[1])."</php>";'
			),
			$PXT
		);
		// TODO: rewrite those below to regexp searching only inside php tags, but not in string, maybe with support of "{$nestedVars} in strings"
//		'%7B' => '{',
//		'%24' => '$',
//		'%7D' => '}',
		// fix PHP tags inside HTML attr
		$PXT = preg_replace_callback(
			'@(<[^>]+?\\w\\s*=\\s*)([\'"])?&lt;\\?php(.*?)(\\?&gt;)\\2@',
			create_function('$m',
				'return $m[1].$m[2]."<?php"
					.str_replace("%20", " ", htmlspecialchars_decode($m[3]))
					."?>".$m[2];'
			),
			$PXT
		);
		return str_replace(
			array_keys(self::$replace),
			self::$replace,
			$PXT
		);
	}
	protected static function isPhpQuery($var) {
		return is_object($var) && get_class($var) == 'phpQueryClass';
	}
	protected static function includePhpQuery() {
		if (! class_exists('phpQueryClass'))
			require_once( dirname(realpath(__FILE__)).'/phpQuery.php');
		phpQueryClass::$debug = self::$debug;
	}
}
?>